pulsedeck 0.2.0

A focused terminal internet radio player with fast search, saved stations, themes, visualizers, and resilient playback
diff --git a/src/audio/buffer_meter.rs b/src/audio/buffer_meter.rs
index 973109a..62624fe 100644
--- a/src/audio/buffer_meter.rs
+++ b/src/audio/buffer_meter.rs
@@ -16,6 +16,8 @@ pub(super) struct BufferStatusMeter {
 struct BufferStatusState {
     historical_velocity: f32,
     last_consumed_at: Option<Instant>,
+    last_sent_percent: Option<u8>,
+    last_sent_seconds: Option<u32>,
 }
 
 impl BufferStatusMeter {
@@ -27,6 +29,8 @@ impl BufferStatusMeter {
             state: Mutex::new(BufferStatusState {
                 historical_velocity: fallback_velocity,
                 last_consumed_at: None,
+                last_sent_percent: None,
+                last_sent_seconds: None,
             }),
         }
     }
@@ -37,17 +41,15 @@ impl BufferStatusMeter {
         capacity: usize,
         status_tx: &mpsc::Sender<AudioStatus>,
     ) {
-        let (percent, seconds) = {
-            let state = self.state.lock().unwrap();
-            buffer_status_from_velocity(
-                len,
-                capacity,
-                state.historical_velocity,
-                self.fallback_velocity,
-            )
-        };
+        let mut state = self.state.lock().unwrap();
+        let (percent, seconds) = buffer_status_from_velocity(
+            len,
+            capacity,
+            state.historical_velocity,
+            self.fallback_velocity,
+        );
 
-        send_buffer_status(status_tx, percent, seconds);
+        send_buffer_status_if_changed(status_tx, &mut state, percent, seconds);
     }
 
     pub(super) fn record_consumed(
@@ -61,40 +63,50 @@ impl BufferStatusMeter {
             return;
         }
 
-        let (percent, seconds) = {
-            let mut state = self.state.lock().unwrap();
-            let now = Instant::now();
-            let delta_t = state
-                .last_consumed_at
-                .replace(now)
-                .map(|last| now.saturating_duration_since(last).as_secs_f32())
-                .unwrap_or(0.0);
-
-            if delta_t >= MIN_MEASURED_SECONDS {
-                buffer_level_status_adaptive_with_fallback(
-                    len,
-                    capacity,
-                    &mut state.historical_velocity,
-                    bytes_read,
-                    delta_t,
-                    self.fallback_velocity,
-                )
-            } else {
-                buffer_status_from_velocity(
-                    len,
-                    capacity,
-                    state.historical_velocity,
-                    self.fallback_velocity,
-                )
-            }
+        let mut state = self.state.lock().unwrap();
+        let now = Instant::now();
+        let delta_t = state
+            .last_consumed_at
+            .replace(now)
+            .map(|last| now.saturating_duration_since(last).as_secs_f32())
+            .unwrap_or(0.0);
+
+        let (percent, seconds) = if delta_t >= MIN_MEASURED_SECONDS {
+            buffer_level_status_adaptive_with_fallback(
+                len,
+                capacity,
+                &mut state.historical_velocity,
+                bytes_read,
+                delta_t,
+                self.fallback_velocity,
+            )
+        } else {
+            buffer_status_from_velocity(
+                len,
+                capacity,
+                state.historical_velocity,
+                self.fallback_velocity,
+            )
         };
 
-        send_buffer_status(status_tx, percent, seconds);
+        send_buffer_status_if_changed(status_tx, &mut state, percent, seconds);
     }
 }
 
-fn send_buffer_status(status_tx: &mpsc::Sender<AudioStatus>, percent: u8, seconds: u32) {
-    let _ = status_tx.send(AudioStatus::BufferLevel { percent, seconds });
+fn send_buffer_status_if_changed(
+    status_tx: &mpsc::Sender<AudioStatus>,
+    state: &mut BufferStatusState,
+    percent: u8,
+    seconds: u32,
+) {
+    let changed =
+        state.last_sent_percent != Some(percent) || state.last_sent_seconds != Some(seconds);
+
+    if changed {
+        state.last_sent_percent = Some(percent);
+        state.last_sent_seconds = Some(seconds);
+        let _ = status_tx.send(AudioStatus::BufferLevel { percent, seconds });
+    }
 }
 
 fn buffer_level_status_adaptive_with_fallback(
@@ -217,4 +229,56 @@ mod tests {
         assert!(second > 8_000.0);
         assert!(second < first);
     }
+
+    #[test]
+    fn buffer_status_sends_first_measurement() {
+        let meter = BufferStatusMeter::new(16_000);
+        let (tx, rx) = std::sync::mpsc::channel();
+
+        meter.report_fill_level(160_000, 1_000_000, &tx);
+
+        assert!(matches!(
+            rx.try_recv(),
+            Ok(AudioStatus::BufferLevel {
+                percent: 16,
+                seconds: 10
+            })
+        ));
+    }
+
+    #[test]
+    fn buffer_status_suppresses_identical_measurements() {
+        let meter = BufferStatusMeter::new(16_000);
+        let (tx, rx) = std::sync::mpsc::channel();
+
+        meter.report_fill_level(160_000, 1_000_000, &tx);
+        meter.report_fill_level(160_000, 1_000_000, &tx);
+
+        assert!(rx.try_recv().is_ok());
+        assert!(rx.try_recv().is_err());
+    }
+
+    #[test]
+    fn buffer_status_sends_when_percent_changes() {
+        let meter = BufferStatusMeter::new(16_000);
+        let (tx, rx) = std::sync::mpsc::channel();
+
+        meter.report_fill_level(160_000, 1_000_000, &tx);
+        meter.report_fill_level(170_000, 1_000_000, &tx);
+
+        assert!(rx.try_recv().is_ok());
+        assert!(rx.try_recv().is_ok());
+    }
+
+    #[test]
+    fn buffer_status_sends_when_seconds_changes() {
+        let meter = BufferStatusMeter::new(16_000);
+        let (tx, rx) = std::sync::mpsc::channel();
+
+        meter.report_fill_level(160_000, 1_000_000, &tx);
+        meter.report_fill_level(168_000, 1_000_000, &tx);
+
+        assert!(rx.try_recv().is_ok());
+        assert!(rx.try_recv().is_ok());
+    }
 }
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index 26719ce..dfaee5e 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -9,18 +9,26 @@ pub mod stations;
 pub mod theme;
 
 use ratatui::prelude::*;
-use ratatui::widgets::{Block, Paragraph};
+use ratatui::widgets::{Block, Borders, Paragraph};
 
 use crate::app::{App, InputMode, LayoutMode};
 
+const MIN_REQUIRED_WIDTH: u16 = 80;
+const MIN_REQUIRED_HEIGHT: u16 = 24;
+
 /// Render the entire UI. Root layout composition.
 pub fn draw(frame: &mut Frame, app: &App) {
     let size = frame.area();
 
-    // Fill background with pure black
-    let bg = Block::default().style(Style::default().bg(theme::bg()));
+    // Fill background with the active theme before any layout work.
+    let bg = Block::default().style(theme::clear());
     frame.render_widget(bg, size);
 
+    if is_compact_terminal(size) {
+        render_compact_terminal_warning(frame, size);
+        return;
+    }
+
     let is_searching = app.input_mode == InputMode::Search;
 
     // Main vertical layout: header | separator | main content split | separator | controls
@@ -129,3 +137,28 @@ pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
         ])
         .split(popup_layout[1])[1]
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn compact_terminal_rejects_width_below_minimum() {
+        assert!(is_compact_terminal(Rect::new(0, 0, 79, 24)));
+    }
+
+    #[test]
+    fn compact_terminal_rejects_height_below_minimum() {
+        assert!(is_compact_terminal(Rect::new(0, 0, 80, 23)));
+    }
+
+    #[test]
+    fn compact_terminal_accepts_exact_minimum() {
+        assert!(!is_compact_terminal(Rect::new(0, 0, 80, 24)));
+    }
+
+    #[test]
+    fn compact_terminal_accepts_larger_terminal() {
+        assert!(!is_compact_terminal(Rect::new(0, 0, 120, 40)));
+    }
+}
diff --git a/src/ui/stations.rs b/src/ui/stations.rs
index 367dde4..bf26025 100644
--- a/src/ui/stations.rs
+++ b/src/ui/stations.rs
@@ -94,7 +94,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) {
             let fixed_width =
                 visible_len(cursor) + visible_len(save_marker) + visible_len(&meta_chip) + 2;
             let name_width = row_width.saturating_sub(fixed_width).max(8);
-            let name = truncate_with_ellipsis(station.name.as_str(), name_width);
+            let search_query = if app.input_mode == InputMode::Search {
+                Some(app.search_query.as_str())
+            } else {
+                None
+            };
+            let name = truncate_station_name(station.name.as_str(), search_query, name_width);
             let padding = row_width.saturating_sub(
                 visible_len(cursor)
                     + visible_len(save_marker)
@@ -121,7 +126,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) {
                 .borders(Borders::ALL)
                 .border_style(theme::border())
                 .border_type(ratatui::widgets::BorderType::Rounded)
-                .style(Style::default().bg(theme::bg())),
+                .style(theme::clear()),
         )
         .highlight_style(theme::selected())
         .highlight_symbol("");
@@ -202,6 +207,69 @@ fn empty_fallback<'a>(value: &'a str, fallback: &'a str) -> &'a str {
     }
 }
 
+fn truncate_station_name(value: &str, query: Option<&str>, max_chars: usize) -> String {
+    match query.map(str::trim).filter(|query| !query.is_empty()) {
+        Some(query) => adaptive_search_truncate(value, query, max_chars),
+        None => truncate_with_ellipsis(value, max_chars),
+    }
+}
+
+fn adaptive_search_truncate(value: &str, query: &str, max_chars: usize) -> String {
+    let value_len = visible_len(value);
+    if value_len <= max_chars {
+        return value.to_string();
+    }
+
+    if max_chars <= 1 {
+        return "…".to_string();
+    }
+
+    let Some(match_start) = find_case_insensitive_char_index(value, query) else {
+        return truncate_with_ellipsis(value, max_chars);
+    };
+
+    if match_start < max_chars.saturating_sub(1) {
+        return truncate_with_ellipsis(value, max_chars);
+    }
+
+    let available = max_chars.saturating_sub(2);
+    if available == 0 {
+        return "…".to_string();
+    }
+
+    let query_len = visible_len(query).max(1);
+    let context_before = available.saturating_sub(query_len) / 2;
+    let start = match_start
+        .saturating_sub(context_before)
+        .min(value_len.saturating_sub(available));
+    let end = start + available;
+
+    if start == 0 {
+        return truncate_with_ellipsis(value, max_chars);
+    }
+
+    if end >= value_len {
+        let tail_width = max_chars.saturating_sub(1);
+        let tail_start = value_len.saturating_sub(tail_width);
+        let tail = value.chars().skip(tail_start).collect::<String>();
+        return format!("…{tail}");
+    }
+
+    let window = value
+        .chars()
+        .skip(start)
+        .take(available)
+        .collect::<String>();
+    format!("…{window}…")
+}
+
+fn find_case_insensitive_char_index(value: &str, query: &str) -> Option<usize> {
+    let value_lower = value.to_lowercase();
+    let query_lower = query.to_lowercase();
+    let byte_index = value_lower.find(&query_lower)?;
+    Some(value_lower[..byte_index].chars().count())
+}
+
 fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
     let value_len = visible_len(value);
     if value_len <= max_chars {
@@ -253,4 +321,52 @@ mod tests {
             "US · 128k"
         );
     }
+
+    #[test]
+    fn search_truncation_keeps_matching_suffix_visible() {
+        let truncated = truncate_station_name(
+            "SomaFM Deep Space One Underground 80s",
+            Some("Underground"),
+            18,
+        );
+
+        assert!(truncated.starts_with('…'));
+        assert!(truncated.contains("Underground"));
+    }
+
+    #[test]
+    fn search_truncation_keeps_matching_tail_visible() {
+        let truncated = truncate_station_name("SomaFM Deep Space One", Some("Space One"), 12);
+
+        assert!(truncated.starts_with('…'));
+        assert!(truncated.contains("Space One"));
+    }
+
+    #[test]
+    fn search_truncation_falls_back_when_query_is_blank() {
+        assert_eq!(
+            truncate_station_name("SomaFM Deep Space One", Some("   "), 10),
+            "SomaFM De…"
+        );
+    }
+
+    #[test]
+    fn search_truncation_falls_back_when_query_is_missing() {
+        assert_eq!(
+            truncate_station_name("SomaFM Deep Space One", Some("jazz"), 10),
+            "SomaFM De…"
+        );
+    }
+
+    #[test]
+    fn search_truncation_handles_tiny_width() {
+        assert_eq!(truncate_station_name("SomaFM", Some("fm"), 1), "…");
+    }
+
+    #[test]
+    fn search_truncation_is_unicode_safe() {
+        let truncated = truncate_station_name("São Paulo Rádio Underground", Some("rádio"), 10);
+
+        assert!(truncated.contains("Rádio"));
+    }
 }